序列化探索 - Protobuf
序列化探索之Protobuf
Protobuf是谷歌提出的一种高压缩比的序列化格式,二进制,不可读,语言无关,平台无关。拥有自己的语法规则,压缩编码算法,并提供主流语言的API生成器(即Protobuf编译器),其序列化结果很小,能够有效节省带宽。
掌握Protobuf,需要比较了解三个方面,其中,如果只是单纯滴使用,前两个方面即可。
- proto语法规则,即proto文件的语法规则
- 具体语言的API生成及使用规则,即通过proto文件生成对应语言的代码
- 序列化和反序列化算法
语法
截止目前,proto有两个版本,proto2和proto3,我们关注proto3。这里展示一个完整的场景,考虑一个获取资源的rpc协议
1 | syntax = "proto3"; |
资源定义在这里
1 | syntax = "proto3"; |
就上面用到的进行说明,也就七七八八了,其它的可以参考官方文档自己去补。
proto结构
- 语法版本声明,如果不声明,默认为proto2
- 导入的proto依赖
- 包声明
- 可选参数设置
- service声明
- 消息声明
其中,除了消息声明,其它都不是必须的,主要的语法内容,也集中在消息声明
数据类型
完整的类型声明参考官方文档,总结如下
- int32:占用字节数是变化的;能表示负数,但效率不是很高
- uint32:占用字节数是变化的;无符号
- sint32:占用字节数是变化的;能表示负数,效率较高
- fixed32:定长,当数字真的非常大时,它比uint32更有效率
- sfixed32:同上,只不过是有符号的
- string:UTF8编码,最长2^32个字节
- bytes:最长2^32个字节
枚举
枚举定义参考上面的Resource.Type,要点
- 必须有一个的编号是0,以便设置默认值;且必须是第一个元素,以便于proto2兼容
- 由于其实际上是使用变长编码,因此编号尽量不好是负数
修饰
repeated
字段只有单数和复数,复数用repeated修饰符,对应的Java中的集合。
oneof
可使用oneof设置字段多选一,但是使用之前需要使用hasxxx()方法查看是否有值。
oneof里面不能使用repeated
序列化时,如果为多个字段都设置值,只有最后一个设定的值会被保留,其它会被清除,反序列化同理
针对oneof的版本升级问题一般不建议做,会有丢失数据的问题
比如新版本将其中一个元素移出去了,那么新的协议得到的序列化结果用旧的协议来解析,则移出去那个元素有值,oneof本身内部有一个有效值,由于旧版本都在oneof内部,因此会丢失一个对新版本来说有效的值。
map
- key可以是除了浮点数、bytes和枚举之外的任意值
- value可以是除了map之外的任意值
- map不能是repeated
- map内顺序不被保证
Any
- 能够包含任意被序列化为bytes的类型
- 有专门的方法去包装和拆解它:pack()和unpack()
注意事项
- 一旦一个编号被使用,在之后的升级中,它将不能再被使用,否则会导致反序列化出错
- 字段编号并非无穷,在1到2^29次方之间;但19000到19999之间的是Protocol Buffers预留的,不能使用
- 如果不给字段赋值,将会设置为默认值,各种类型默认值如下
- 字符串:空串
- 字节数组:空数组
- bool:false
- 数字:0
- 枚举:编号是0的那个值
- 消息字段:取决于具体语言,Kotlin和Java中
- proto的导入不可跨层传递,除非用
import public
,但这在Java中是不支持的 - protobuf可以和Json之间直接映射,有兴趣可以去研究一下
序列化算法
Protobuf序列化的目标单位是一条消息(message),其算法,就是一个压缩算法,总体要点有几个
- 以序号替代字段名
- 对一般的数字类型和布尔采用变长表示法,这样一个int32类型的字段最低只要一个字节即可表示完成
- 将所有字段类型分为几类,分别有不同的表示方法
- 对string或自定义的结构化类型,只是多了一个长度标识
一个例子
下面以一个序列化说明
1 | message Resource { |
我们按照如下设置值
1 | id = 2 |
其对应的代码为
1 | fun main() { |
它的序列化结果将是
1 | 08021201521a050a03e68891 |
前置知识
二进制流组成:字段编号<<3 + 编码类型 + 字段值
字段值为复杂类型时,将会递归使用上述编码方式
编码类型如下
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimited | string, bytes, embedded messages, packed repeated fields |
3 | Start group | groups (deprecated) |
4 | End group | groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
例子分析
现在我们再来看上面的输出:
08 -> 00001 000 :字段编号为1,类型为0
02 -> 0000 0010 :字段值为2
12 -> 00010 010 :字段编号为2,类型为2,即复杂字段,这个类型的下一个字节标识了长度
01 -> 0000 0001 :字段编号为2的字段值长度为1
52 -> 0101 0010 :字段编号为2的字段值的UTF8编码,即”R“
1a -> 00011 010 :字段编号为3,类型为2
05 -> 0000 0101 :字段编号为3的字段值长度为5,对应我们的嵌套类型Data
0a -> 00001 010 :字段编号为1,类型为2
03 -> 0000 0011 :字段编号为1的字段值长度为3
e68891 -> 字段编号为1的字段值的UTF8编码,即”我“
结束
如何做到版本兼容
由于它是根据字段编号标识字段,反序列化时,遇到不认识的字段编号会直接忽略。因此版本兼容的方式就是,字段编号只增不减,不可与之前的编号复用。复用在protobuf里是绝对不允许的。
API生成规则
使用protobuf编译器,能够生成对应语言的代码,我们主要看Java和Kotlin,由于Kotlin是在Java生成的基础上生成的,因此一起讲了。
其主要能力,是对proto中定义的消息进行构建,然后提供转换为流的方式;如果加上特定RPC插件,还可以生成Service的代码。
Java文件生成
- 文件拆分:如果设置了
option java_multiple_files = true;
,则会按照顶层的Service和Message分别生成多个Java源文件 - 包名:如果设置了
option java_package = "xx.xx.xx.xxx";
,则以它为准;否则以proto文件定义的package为准 - 字段名:proto中为小写+下划线,Java文件中转换为小驼峰命名
关键字段类型
- repeated:转换为ProtocolStringList类型,其直接继承了java.util.List
- 枚举:转换为普通枚举类型,但是会多一个UNRECOGNIZE,表示未知参数
- oneof:多选一的字段,每个字段都会生成一个
hasXXX()
方法,用以判断是否有值;也有提供hasOneof()
,判断这几个字段是否有存在一个 - map:转换为
java.util.Map
类型 - Any:转换为
com.google.protobuf.Any
,它提供pack和unpack,用于将其它任意消息类型进行封装和解封
消息类型的构建
- 消息是不可变的,一旦构建不可更改
- 消息构建采用建造器模式,典型地如
Resource.newBuilder().setType(Resource.Type.RECORD).build()
- 嵌套消息,其建造器也是嵌套的,嵌套在父建造器上:
Resource.newBuilder().recordBuilder.addImages("").build()
Service
尽管我们在proto文件中定义了Service,但是脱离具体的RPC实现(比如gRPC),这个Service就没有意义。且一般的RPC实现会作为插件的形式载入(所以你看很多gradle中配置grpc是以插件的形式声明在protobuf块中的),生成两个部分
- 服务抽象类:定义服务端,用户通过它实现自己的服务端逻辑
- 客户端Stub:定义客户端,用户通过它连接远端服务
proto文件中存在option java_generic_services = true;
时,会生成一个通用的Service实现。有兴趣可以看一下。
其它
- 每个消息,还提供了Descriptor,用以描述其proto信息,类似反射,可以获取元信息,如果我们写自己的RPC插件,可以使用
- 每个消息,都提供
getDefaultInstance()
,返回一个空对象
来个例子(Java)
这里仅展示将上面定义的Resource消息设置值,并将序列化结果以流的形式输出。
1 | fun main() { |
来个例子(Kotlin)
Kotlin生成在Java之上,为我们提供了DSL,上面的代码,用DSL再写一遍,看起来明显好很多。
1 | fun main() { |
环境问题
API生成,可以有两种方式
- protoc命令行生成API,gradle只引入相关依赖。好处是简单;缺点是一次性,每次修改协议都要生成一次。
- 直接使用gradle插件,将protoc生成命令嵌入gradle生命周期。好处是一旦配置完成就一劳永逸;坏处是配置麻烦。
protobuf简单是简单,但配置起来还是有几个问题的。问题主要在于kotlin代码的生成,gradle插件的配置
关于protoc的安装
需要在项目发布页下载压缩包进行安装,macos直接选择对应的编译好的二进制包,有先尝试过直接brew install protobuf
,结果它不包含protoc-kotlin-gen插件。
包的引入
协议相关包,在这里才是最完整的,无论java也好,kotlin也好。
Kotlin的生成
kotlin API需要依赖java代码,因此要同时生成java
1 | $ protoc --java_out=xxxxxx --kotlin_out=xxxxxx resource_service.proto |
生成的Java代码
生成的Kotlin代码
Gradle插件
插件的项目地址:我是插件的项目地址
配置要点
- 添加protobuf插件的依赖
- 通过sourceSets指定proto文件位置
- protobuf.generatedFilesBaseDir指定代码生成位置
- protobuf.protoc指定protoc的版本
- protobuf.generateProtoTasks定制
1 | buildscript { |
kotlin代码的插件配置方式,暂时还没找到
总结
Protobuf优点众多:语法简单;编码算法使得结果非常小巧;编码算法也不难理解;对主流语言提供了API生成支持。但代码生成这种方式增加了构建时间,实际体验并不是很好。
有关更多内容,请需要参考官方文档